Skip to content

feat(spec,metadata-protocol): cloneData reports droppedFields like every other create face — CloneDataResponseSchema gains the optional member - #16823

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-15703-clonedata-dropped-fields
Sep 8, 2026
Merged

feat(spec,metadata-protocol): cloneData reports droppedFields like every other create face — CloneDataResponseSchema gains the optional member#16823
os-zhuang merged 4 commits into
mainfrom
claude/issue-15703-clonedata-dropped-fields

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #15703

Clause-②: yes — this PR widens a published response contract (CloneDataResponseSchema, @objectstack/spec/api, declared AS PRODUCED under the 2026-08-25 ruling on #11924) and is dispatched at the contract-review tier for that reason. needs:contract-review is applied.

Ruling executed

Comment 5578819560 on #15703 (director seat, decision batch #83, 2026-09-08, maintainer verbatim 「其他同意」) — option 1, four parts, all in this PR:

  1. CloneDataResponseSchema gains an optional droppedFields member of the same Zod shape as CreateDataResponseSchema's (z.array(DroppedFieldsEventSchema).optional()).
  2. cloneData passes the onFieldsDropped listener exactly as createData does (const dropped = []; opts.onFieldsDropped = (e) => dropped.push(e); spread droppedFields onto the return only when non-empty).
  3. The two conformance pins in packages/metadata-protocol/src/search-clone-schema-conformance.test.ts and PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395's firing-control pin in protocol.readonly-insert.test.ts flip in the same PR.
  4. Changeset: @objectstack/spec minor.

Sequencing honoured: the member and the producer land together — the conformance suite measures the produced member on the real producer, not a hand-built body.

What changed

File Change
packages/spec/src/api/protocol.zod.ts CloneDataResponseSchema.droppedFields (optional, same element schema as CreateDataResponseSchema); docblock rewritten from "the producer emits no droppedFields" to the ruling. The .describe() deliberately does NOT copy CreateDataResponseSchema's sentence about the X-ObjectStack-Dropped-Fields header — measured: the clone route relays the body verbatim (res.status(201).json(result), packages/rest/src/rest-server.ts) and never calls applyDroppedFieldsHeader (only the single create at ~8608 and single update at ~8750 do), so the describe says "body only, no header".
packages/metadata-protocol/src/protocol.ts cloneData wires the listener and spreads droppedFields — byte-for-byte the createData pattern; the strip itself is unchanged and stays the engine's.
search-clone-schema-conformance.test.ts Fixture object gains a static readonly column and the engine double now strips + fires onFieldsDropped (a double that never dropped would leave the produced branch vacuously green). Pin 2 flips from "no droppedFields, by name" to "declared AND produced" (event asserted, safeParse raw and over the wire, key set still a subset of the declaration); a new third case pins omit-when-empty on the no-drop body. Pin 1 (no-drop body parses) unchanged.
protocol.readonly-insert.test.ts The clone delegation case now also asserts the 201 body reports BOTH the copied approval_status and the overrides-forged source in the engine's one event; the firing-control enumeration grows to 7 inserts (cloneData included); the "cloneData is the one face that passes NO listener" case is replaced by a named presence pin. Header comment updated.
packages/objectql/src/protocol-data.test.ts Pin the card did not name: forwards context to findOne and insert asserted insert was called with EXACTLY { context }; now { context, onFieldsDropped: expect.any(Function) }.
packages/client/src/index.ts CloneDataResult (declared mirror of CloneDataResponseSchema, return type of client.data.clone) gains the same optional droppedFields?: DroppedFieldsEvent[]. Beyond the three ruled files, and flagged for the reviewer: its published docblock said in as many words "No droppedFields: the clone producer emits no write-observability event" — a sentence this PR makes false in a shipped .d.ts, so it is fixed here under the "a published claim your change falsifies is yours" rule. Drop it if the seat wants the PR strictly at the ruled three; nothing else depends on it.
.changeset/clone-data-dropped-fields.md @objectstack/spec minor (ruled); @objectstack/metadata-protocol minor (the 201 body gains a member); @objectstack/client minor (published type widened). Not declared breaking — no ADR-0087 marker owed.
packages/spec/authorable-surface/api.json, content/docs/references/api/protocol.mdx Regenerated by the build / check:generated --fix (only the docs were proved stale; the surface shard is written by gen:schema inside build). authorable-surface.base.json untouched.

Premises measured (ZONE 2), against 47f751d5d (origin/main at branch time)

  1. Sequencing precondition — PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395 is merged (merged_at: 2026-09-05T12:24:23Z, merge commit b398ad258, in origin/main's log); its firing-control block is at protocol.readonly-insert.test.ts:254-267 on main. Flipping it fights nothing. Holds.
  2. Current shapeCloneDataResponseSchema was exactly { object, id, sourceId, record } (protocol.zod.ts:2007-2017). Holds.
  3. The two conformance pins — the "in so many words" pin exists: line 227, expect(body).not.toHaveProperty('droppedFields'), plus the key-set-subset check at 221-223. Nuance worth the reviewer's eye: that forbid was measured on a fixture engine that never fired onFieldsDropped, so it was vacuous with respect to the listener — wiring the listener alone would NOT have turned it red. The real teeth against wiring were elsewhere: protocol.readonly-insert.test.ts:266 (onFieldsDropped toBeUndefined) and packages/objectql/src/protocol-data.test.ts:654 (exact options { context }), the latter not named on the card. "Flipping" the conformance pin therefore meant giving the fixture a readonly column and a double that fires the event, so the produced branch is measured — not merely deleting a negation.
  4. "Five of six report" — measured at the implementation: createData, createManyData, insertManyData, batchData create rows and both creating upsert arms all pass onFieldsDropped and surface droppedFields (the existing firing-control test enumerates 6 insert call sites, all with a listener); cloneData passed only ctxOpt. Holds.
  5. CreateDataResponseSchema's shape — read, not inferred: droppedFields: z.array(DroppedFieldsEventSchema).optional().describe(...). Copied as the Zod shape; the describe prose differs only where the fact differs (the header, see above). Holds.
  6. cloneData passes no listener todaythis.engine.insert(request.object, data, ctxOpt as any), ctxOpt is { context } or undefined. Holds.

Verification

Filled in from the run record — see the report comment on #15703 for the full command/exit table. Summary at the head sha this body names:

Every build/test/typecheck ran through scripts/pm/os-verify-lock.sh; every exit was captured before any pipe (cmd > log 2>&1; ex=$?). Two heads because origin/main was merged in after the first round (AGENTS.md §10); the hand-written delta is byte-identical across the merge (diff-of-diffs with cmp), and packages/spec moved on both sides, so its full suites were re-run on the merged head.

Step Head Command Result
spec build b617ce886 and again at 68c74383f pnpm --filter @objectstack/spec build VERDICT command-exit 0 (204s / 209s); rebuilt dist/api/index.d.ts carries droppedFields inside CloneDataResponseSchema (positive control: 15 droppedFields hits in the same file)
spec generated artifacts b617ce886 then 68c74383f pnpm --filter @objectstack/spec check:generated (then --fix) pre-merge: 1 of 15 stale (content/docs/references/**gen:docs), regenerated and committed; post-merge: ✓ All 15 generated artifacts are up to date.
dependency closure b617ce886 turbo run build --filter='@objectstack/objectql^...' --filter=@objectstack/metadata-protocol --filter='!@objectstack/spec' --concurrency=2 14 successful, 14 total; metadata-protocol/dist/index.js carries the spread droppedFields at the clone return
metadata-protocol (own package) b617ce886 pnpm --filter @objectstack/metadata-protocol test Test Files 169 passed | 2 skipped (171), Tests 2434 passed | 10 skipped (2444)
metadata-protocol typecheck b617ce886 pnpm --filter @objectstack/metadata-protocol typecheck (tsc --noEmit) exit 0, 0 error TS lines
the two flipped files, explicitly 68c74383f pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/search-clone-schema-conformance.test.ts src/protocol.readonly-insert.test.ts Test Files 2 passed (2), Tests 19 passed (19)
objectql (importer whose pin flipped) b617ce886 and 68c74383f pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/protocol-data.test.ts src/protocol-clone-real-engine.test.ts Test Files 2 passed (2), Tests 121 passed (121) both times
objectql test-layer typecheck b617ce886 pnpm --filter @objectstack/objectql run check:test-typecheck check:test-typecheck: OK — @objectstack/objectql's test layer compiles … 44 file(s) / 242 error(s) / 69 pinned signature(s) held (the ledgered debt, unchanged)
client src typecheck b617ce886 and 68c74383f pnpm --filter @objectstack/client exec tsc --noEmit exit 0, empty output, both times
spec (own package) b617ce886 and 68c74383f pnpm --filter @objectstack/spec test Test Files 465 passed (465), Tests 12955 passed (12955) both times
spec typecheck b617ce886 and 68c74383f pnpm --filter @objectstack/spec typecheck exit 0; check:test-typecheck: OK — … 54 file(s) / 261 error(s) / 145 pinned signature(s) (ledgered, unchanged)

NOT MEASURED locally, declared to CI: @objectstack/client's full pnpm test and its check:test-typecheck — its unit tests import @objectstack/runtime / @objectstack/objectql / @objectstack/plugin-hono-server unaliased (dist), which are outside the closure built here; the client change is a type-only widening of CloneDataResult, and client.data.clone's binding pin (return-type-precision.test.ts) is type-level. Also pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt — both answered PREREQUISITE NOT MET (exit 3: 57 packages without dist/); a refusal, not a pass.

Gate sweep (lock-free, on the merged head 68c74383f): node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 103 families from the merge-base change set (the pre-merge derivation was 80, then a STALE TREE warning named 8 gate scripts moved upstream — the reason origin/main was merged before this PR opened rather than trusted or ignored). All 103 ran, each exit captured before any pipe; --ran reconciles 103 derived, 103 run, 0 UNRUN. 101 exit 0 — including check:nul-bytes, check:empty-changeset, check:adr-0087-registration, check:changeset-no-major, check:pm-widening-tells, check:engine-double-contract, spec check:api-surface / check:authorable-surface / check:docs / check:liveness / check:skill-examples (the last measured only after @objectstack/client and @objectstack/client-react were built — its first run was a build-prerequisite refusal). The 2 non-zero are the exit-3 refusals named above (NOT MEASURED, not red). check:react-declaration-parity is EXTERNAL_INPUT_REQUIRED here as always.

Ablation (the listener wiring deleted, everything else intact)

Script: scratchpad/issue-15703/ablation.sh (not committed), run under the lock at b617ce886 (VERDICT command-exit 0 · held 58s), trap restore EXIT INT TERM with absolute paths. Both legs proven on disk, never by an editor's exit code.

Mutation: in cloneData only, const opts: any = { onFieldsDropped: … } becomes const opts: any = { /* ABLATION_15703_NO_LISTENER */ } — proven by grep -c: marker 1, the neighbouring opts.context line still 1, the three identical listener spellings in the other faces untouched (count 3).

Leg Resolution Command Result
mutated — metadata-protocol pins src/ (this package's tests import ./protocol.js) vitest run --maxWorkers=2 src/search-clone-schema-conformance.test.ts src/protocol.readonly-insert.test.ts exit 1 — Tests 4 failed | 15 passed (19): cloneData forwards the copied row AND the caller overrides whole — and reports the engine's verdict on both (expected undefined to deeply equal [ { object: 'approval_case', … } ]); every create face whose response carries droppedFields passes an onFieldsDropped listener (expected 'undefined' to be 'function'); cloneData passes the listener too — the sixth face (same); emits no top-level key the spec does not declare — droppedFields included, now that it is declared AND produced (expected undefined to deeply equal [ { object: 'customer', … } ])
mutated — objectql pin dist/ (objectql's tests import @objectstack/metadata-protocol unaliased — it is in KNOWN_UNALIASED_TEST_IMPORTS) pnpm --filter @objectstack/metadata-protocol build (exit 0) → node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATION_15703_NO_LISTENER (exit 0, marker present in dist) → vitest run --maxWorkers=2 src/protocol-data.test.ts exit 1 — Tests 1 failed | 116 passed (117): cloneData › forwards context to findOne and insert (expected "vi.fn()" to be called with arguments: [ 'account', Anything, { …(2) } ])
restore git checkout HEAD -- ABS_PATH (absolute path from git rev-parse --show-toplevel) git diff HEAD 0 lines; git hash-object = HEAD blob 67bcd138cc046aaf053bbe61ad23956b70e80a52 (equal); marker count 0; git status --porcelain empty
restored — rebuild + preflight dist/ pnpm --filter @objectstack/metadata-protocol build (exit 0) → preflight --absent exit 0: marker absent from all 24 built files, working tree clean against HEAD
restored — both suites src/ and dist/ the same two vitest commands metadata-protocol Tests 19 passed (19); objectql Tests 117 passed (117)

Direction observed: red, as expected for a deleted producer — no reversal, no "more diagnostics" shape. The green suites prove the pins exist; the red legs prove they bind to exactly the listener wiring.

验收备注 (acceptance notes — observations, not filed)

  • X-ObjectStack-Dropped-Fields on the clone route — after this PR the clone's 201 body carries droppedFields but the route sets no header, while POST /data/:object and PATCH /data/:object/:id set both (bulk routes set neither). Not a defect class (a missing member is incompleteness, not an error, and no contract text promises the clone route a header), and deliberately not added here: it is a wire change the ruling did not name and the tier reviewer may want to rule on. The schema describe states the body-only fact so no reader is misled. 承接者: the contract reviewer of this PR, if they want it; otherwise none.
  • .changeset/tidy-cups-smile.md (PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395's pending changeset) still says "cloneData forwards whole but reports nothing on the wire … adding that key is a spec change, not part of this one". Left untouched — it is the accurate record of THAT change; this PR's changeset states the new fact. When both release together the CHANGELOG will read as history. 承接者: whoever compiles release notes.
  • docs/qa/platform-checklist/areas/records-forms.json item "Clone contract edges" describes the body as {object, id, sourceId, record}; still true for a no-drop clone, and the item's readonly-forge step could now additionally assert droppedFields. 承接者: the next checklist-author sweep.
  • Two dead ctxOpt consumers? No — ctxOpt is still used for findOne; only the insert call moved to opts.

Docs drift (hand-written pages the drift bot listed — read and decided, not regenerated)

Measured on this branch: none of the five listed pages names CloneDataResult (0 hits each; control: content/docs/references/api/protocol.mdx names CloneDataResponse 4 times), so the bot bridged the bare key droppedFields, not the client type — there is no "same type / different type" question to settle on those pages.

Page Decision Why
content/docs/api/data-api.mdx changed Its clone section stated the 201 body as exactly { object, id, sourceId, record } — a sentence this PR falsifies. Now { object, id, sourceId, record, droppedFields? }, with when it appears (a copied or overridden readonly column), what it means, and that it rides the body only (no X-ObjectStack-Dropped-Fields header on this route).
content/docs/kernel/contracts/data-engine.mdx left Its layering callout says the create faces "surface this seam's onFieldsDropped as their response droppedFields wherever their contract declares one". True before (cloneData's contract declared none) and true after (it now declares one and the producer fills it) — the hedge simply covers one more face. Not falsified; tightening it would be polish.
content/docs/protocol/kernel/http-protocol.mdx left Names droppedFields only as the generic body channel behind the exposed x-objectstack-dropped-fields header ("the response body's droppedFields stays the primary channel"). Route-agnostic and still true; for the clone route the body is the only channel, which the schema describe and data-api.mdx now say.
content/docs/releases/implementation-status.mdx left — release-owned, read-only Lists the clone route as implemented; not falsified.
content/docs/releases/v17.mdx left — release-owned, read-only The v17 write-observability note claims droppedFields for PATCH/POST /data, bulk, batch and the SDK; it never claimed or denied the clone. Not falsified — no docs-only card needed.

packages/spec/authorable-surface/api.json yielded no anchor for that check, so its coverage there is not a clean bill of health; its own gate (check:authorable-surface) is green on this branch and the diff to it is the single added key api/CloneDataResponse:droppedFields.


Generated by Claude Code

… schema member, listener, pins, changeset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
…neDataResponse.droppedFields

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/metadata-protocol, @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via CloneDataResponseSchema (symbol, a top-level const), droppedFields (symbol, a field of interface CloneDataResult), /:object/:id/clone (route, bridged from symbol cloneData — its route source's handler names it))
  • content/docs/kernel/contracts/data-engine.mdx (via cloneData (symbol, a method of class ObjectStackProtocolImplementation), droppedFields (symbol, a field of interface CloneDataResult))
  • content/docs/protocol/kernel/http-protocol.mdx (via droppedFields (symbol, a field of interface CloneDataResult))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via /:object/:id/clone (route, bridged from symbol cloneData — its route source's handler names it))
  • content/docs/releases/v17.mdx (via droppedFields (symbol, a field of interface CloneDataResult))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 133 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d4401f75bbe2e9e1b6f52f9bad83e377c5c33277packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3e9d711477f9432bf4015de6685b069b50e1c4af — the merge of head d117857520a6d6037dd8e1b4627b145b4a0eb390 into base d4401f75bbe2e9e1b6f52f9bad83e377c5c33277, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 3e9d711477f9432bf4015de6685b069b50e1c4af && git checkout 3e9d711477f9432bf4015de6685b069b50e1c4af
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d4401f75bbe2e9e1b6f52f9bad83e377c5c33277 d117857520a6d6037dd8e1b4627b145b4a0eb390 && git checkout -B drift-repro d4401f75bbe2e9e1b6f52f9bad83e377c5c33277 && git merge --no-ff d117857520a6d6037dd8e1b4627b145b4a0eb390

node scripts/docs-audit/affected-docs.mjs --json d4401f75bbe2e9e1b6f52f9bad83e377c5c33277

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs d4401f75bbe2e9e1b6f52f9bad83e377c5c33277 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 8, 2026

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16823 @ d117857

Verdict: PASS WITH FINDINGS (CI-conditional — 17 check runs were still in_progress at review time; the verdict holds if they finish green, and no finding below blocks).

Ruling implemented: yes — all four ruled parts land in this PR, as one change, with the member and the producer together.

Ruling quoted verbatim — comment 5578819560 on #15703, posted by os-zhuang (MEMBER, director seat, decision batch #83, 2026-09-08T03:45Z), relaying the maintainer's live-chat reply:

Maintainer's reply, verbatim (live PM chat, 2026-09-08T03:4xZ, answering the batch #83 presentation where this card was item 1 with recommendation 1):

其他同意

CloneDataResponseSchema gains an optional droppedFields member of the same shape as CreateDataResponseSchema's; cloneData passes the onFieldsDropped listener exactly as createData does; the two conformance pins (search-clone-schema-conformance.test.ts) and PR #15395's firing-control pin in protocol.readonly-insert.test.ts flip in the same PR. @objectstack/spec changeset minor under the bump-level rule.

Sequencing: the schema member and the producer land together ("declared AS PRODUCED" — never a declared member the route does not fill). The PR is Clause-②: yes (a published response contract widens) and goes through the contract-review tier.

So: the maintainer's own words are the two characters 「其他同意」; the four-part expansion is the director seat's recorded reading of "option 1", which is the option the card, triage (5549121865) and the domain seat (5572013366) all defined identically. Nothing in this PR re-adjudicates.

Read through a throwaway ref (refs/review/16823), diffed against merge-base 30b0990787 (the PR contains a merge of origin/main, 68c74383f, so the merge-base diff is the clean measure: 10 files, +197/−40). No checkout, no run — every claim below is from reading the tree and the CI/API state.

1. The four ruled parts, each verified in code

Part Evidence Verdict
1 · optional droppedFields on CloneDataResponseSchema, same Zod element as CreateDataResponseSchema packages/spec/src/api/protocol.zod.ts:2021droppedFields: z.array(DroppedFieldsEventSchema).optional().describe(…); CreateDataResponseSchema at :1975 is z.array(DroppedFieldsEventSchema).optional(). Same element schema (packages/spec/src/data/data-engine.zod.ts:271), same optionality. Only the .describe() prose differs (header sentence — see §3). done
2 · cloneData passes onFieldsDropped like createData packages/metadata-protocol/src/protocol.ts:10902-10905 vs createData at :10808-10811. Byte-identical pattern: const dropped: DroppedFieldsEvent[] = []; const opts: any = { onFieldsDropped: (e) => { dropped.push(e); } }; if (ctx !== undefined) opts.context = ctx; … engine.insert(object, data, opts); return spreads ...(dropped.length > 0 ? { droppedFields: dropped } : {}) at :10917, same as createData:10826. DroppedFieldsEvent was already imported (:83). The strip itself is untouched (engine-side). done
3 · two conformance pins + #15395 firing-control pin flip in the same PR search-clone-schema-conformance.test.ts:249-280 (pin 2 → "declared AND produced") and :282-295 (omit-when-empty, retains not.toHaveProperty('droppedFields') on the no-drop body). protocol.readonly-insert.test.ts:266-267 (was toBeUndefined(), now typeof … toBe('function')), enumeration grows 6→7 (:260-261). PR #15395 is merged (b398ad258 in origin/main), so the flip fights nothing. done
4 · @objectstack/spec minor .changeset/clone-data-dropped-fields.md:2'@objectstack/spec': minor. done

Beyond the ruled three files — judged owed, not widening:

  • packages/client/src/index.ts:432-451CloneDataResult gains droppedFields?: DroppedFieldsEvent[]. The pre-PR docblock at the same site said, in a shipped .d.ts, "No droppedFields: unlike createData, the clone producer emits no write-observability event" — a sentence part 2 makes false. Also, without it a TS caller of client.data.clone reads the member only via cast, i.e. the declared mirror would drift from the schema it names as its source (Spec: CloneDataResponseSchema). Owed under "a published claim your change falsifies is yours". Additive type widening ⇒ @objectstack/client minor is the correct level (WHICH LEVEL: "a new accepted key or value takes at least minor"). Keep it.
  • packages/objectql/src/protocol-data.test.ts:657-660 — the pin asserted insert was called with exactly { context: ctx }; part 2 falsifies it. Owed (the card did not name it; a change that turns an existing exact-args pin red must carry the flip). Test-only in objectql, so no objectql changeset entry is owed (nothing released).
  • content/docs/api/data-api.mdx:358-364 — the clone section stated the 201 body as exactly { object, id, sourceId, record }; falsified, corrected, and the correction states body-only (no header). Owed, non-governed.

2. File enumeration vs merge-base — governed paths: NONE

.changeset/clone-data-dropped-fields.md                              (+15)
content/docs/api/data-api.mdx                                        (hand-written, non-governed)
content/docs/references/api/protocol.mdx                             (GENERATED — see below)
packages/client/src/index.ts
packages/metadata-protocol/src/protocol.readonly-insert.test.ts
packages/metadata-protocol/src/protocol.ts
packages/metadata-protocol/src/search-clone-schema-conformance.test.ts
packages/objectql/src/protocol-data.test.ts
packages/spec/authorable-surface/api.json                            (+1 line)
packages/spec/src/api/protocol.zod.ts

git diff --name-only <mb>..head -- 'docs/adr/**' '.claude/**' 'skills/**' AGENTS.md CLAUDE.md 'content/docs/releases/**' returns empty. No governed path is touched ⇒ a maintainer-only merge is NOT required on governed-surface grounds. (The Clause-② tier gate is a separate floor; this comment is that review.)

content/docs/references/api/protocol.mdx is generated output: its header carries {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. … */} with Source: packages/spec/src/api/protocol.zod.ts; the writer is packages/spec/package.json:254 gen:docs: tsx scripts/build-docs.ts (and check:docs is its --check). The +11 lines are the new row and the Nested Shape: CloneDataResponse.droppedFields[number] table, i.e. exactly what the new .describe() projects. packages/spec/authorable-surface/api.json diff is numstat 1 0 — the single added key api/CloneDataResponse:droppedFields (line 313); authorable-surface.base.json untouched.

3. Clause-② derivation from code

A published response contract (CloneDataResponseSchema, @objectstack/spec/api, declared AS PRODUCED under the 2026-08-25 ruling on #11924) gains an optional member. Additive: a reader of object/id/sourceId/record sees no change; omit-when-empty means a no-drop clone's body is byte-identical to before. Clause-②: yes is correctly declared in the PR body and the needs:contract-review label is present on both PR and card (check-clause2-carriers pair, per the dev report).

The .describe() at protocol.zod.ts:2021-2030 states: "Carried in the 201 body only — this route relays the producer verbatim and sets no X-ObjectStack-Dropped-Fields header." Measured true: packages/rest/src/rest-server.ts:8865-8872 — the clone handler does const result = await cloneData.call(p, {…}); res.status(201).json(result); with no applyDroppedFieldsHeader between them. That helper (packages/rest/src/error-response.ts:2472) is called at exactly two sites in rest-server.ts: :8617 (single create) and :8759 (single update). So the schema prose, data-api.mdx, the client docblock and the changeset all state the same measured fact. Header asymmetry judged in F1.

4. Changeset grading

.changeset/clone-data-dropped-fields.md: @objectstack/spec: minor, @objectstack/metadata-protocol: minor, @objectstack/client: minor. No major (launch-window guard, check-changeset-no-major.mjs, green by construction). No BREAKING banner, so check-adr-0087-registration.mjs is not engaged (its own G1: "a non-breaking changeset is not this gate's business") — correct, since an optional additive member is not breaking and no ADR-0087 disposition is owed.

LEVEL axis (#16055, same script :734-1025): PUBLISHED_SOURCE_PATH = /^packages\/([^/]+)\/src\// (:822). Packages this diff grows: spec, metadata-protocol, client, and objectql (via protocol-data.test.ts). Predicate is entry.bump === 'patch' && grown.has(entry.pkg) (:945): none of the three entries is patch, and objectql has no entry (absence is not flagged; test-only, nothing released) ⇒ clean. Against batch #35 WHICH LEVEL (pr-automation.yml:667-682): "a purely additive widening of a published package's public surface … takes at least minor" — all three widened packages (schema member, 201 body member, .d.ts type member) are graded minor. Correct on every axis. Its own report of #16713 (packages/spec/src, packages/metadata-protocol/src, packages/client/src all match the regex) is consistent with what I derive.

5. Tests

Pins that redden if the listener wiring in cloneData is removed (by reading; each asserts on something only the listener produces):

  1. packages/metadata-protocol/src/protocol.readonly-insert.test.ts · cloneData forwards the copied row AND the caller overrides whole — and reports the engine's verdict on both (:148-155, res.droppedFields toEqual [{ fields: ['approval_status','source'] }]).
  2. same file · every create face whose response carries droppedFields passes an onFieldsDropped listener to the engine (:260-264, 7th insert's options.onFieldsDropped would be undefined).
  3. same file · cloneData passes the listener too — the sixth face … (:281).
  4. packages/metadata-protocol/src/search-clone-schema-conformance.test.ts · emits no top-level key the spec does not declare — droppedFields included, now that it is declared AND produced (#15703) (:268-270).
  5. packages/objectql/src/protocol-data.test.ts · cloneData › forwards context to findOne and insert (:657-660, exact { context, onFieldsDropped: expect.any(Function) }).

The PR's ablation table reports exactly these five going red (4 + 1); consistent with the reading.

Conformance fixture is no longer vacuous: search-clone-schema-conformance.test.ts:192-224makeCloneProtocol now declares approval_status: { readonly: true, defaultValue: 'draft' } on customer, and the engine double iterates the object's fields, deletes each readonly key present in the payload, and calls options.onFieldsDropped({ object, fields, reason: 'readonly' }) exactly when ≥1 was dropped (:212-222). The produced-branch case seeds the SOURCE row with approval_status: 'approved' (:258) — the clone-specific path where the caller typed nothing — and asserts the event, the stripped record, and safeParse success both raw and over-the-wire (:268-280). The readonly-insert fixture (:60-93) already stripped-and-fired before this PR; its clone case now asserts on it.

Omit-when-empty: :282-295 — default source row carries no readonly key ⇒ double fires nothing ⇒ expect(body).not.toHaveProperty('droppedFields') still holds, and now against a double that CAN drop, so it is a real omit-when-empty pin rather than an artefact of a mute fixture. Key-set-subset check retained in both cases.

grep -E '^\+.*\.(skip|only|todo)\(' over the full diff: no hits.

6. CI on d117857 (read 2026-09-08 ~08:48Z) · mergeable_state · distance from main

34 check runs on the head:

  • completed / success (14): Build Docs · filter · Type Check · source gates · Auto Label · Part-of PR must not also close its card · No other open PR may claim the same single-writer path · Governed Surface Queue Guard · Spec property liveness · Check PR Size · Check Changeset · No other open PR may claim the same issue (×2) · Check Documentation Links · Flag docs affected by code changes
  • completed / skipped (2): Console Pin Gate · Packed-tarball smoke (opt-in)
  • completed / cancelled (2): earlier duplicates of "Part-of PR…" and "single-writer path" (runs 34205917707 / 34205917700, superseded by the successful re-runs above — not failures)
  • in_progress (16): Test Core (1–6/6) · Dogfood Regression Gate (1–3/3) · Build Core · Dogfood Verify CLI · Temporal Conformance (live PG + MySQL) · Lint & Repo Gates · Type Check · consumer gates · Type Check · debt ledger · Type Check · workspace
  • failure: 0 at review time.

mergeable_state: blocked — the PR is draft: true, required checks are still running, and no approving review exists; no merge conflict is reported. Head is 2 commits behind origin/main (base sha d4401f7; the PR merged main at 68c74383f, then two more landed upstream — per the dev report, scripts/check-regen-pending.mjs / scripts/git-merge-regen.mjs, not files this diff derives a verdict from).

Not measured locally by the author and declared to CI: @objectstack/client full pnpm test / check:test-typecheck, check:dual-build-cjs-loads, check:type-check-debt. The client change is a type-only widening plus a docblock; the "Type Check · consumer gates" / "workspace" jobs are the ones that will answer it. The CI-conditional in the verdict is specifically these.

Findings

F1 — X-ObjectStack-Dropped-Fields header asymmetry on the clone route · Severity: Low / advisory — no change required in this PR
Evidence: packages/rest/src/rest-server.ts:8872 (res.status(201).json(result), no header) vs :8617 (create) and :8759 (update) which call applyDroppedFieldsHeader. After this PR the clone 201 body carries droppedFields while the route sets no header; POST /data/:object and PATCH /data/:object/:id set both; bulk routes set neither.
Judgement: not a defect. No published text promises the clone route a header (CreateDataResponseSchema's describe scopes the header sentence to itself; http-protocol.mdx names the body as the primary channel), and this PR's schema describe, data-api.mdx, client docblock and changeset all state body-only, so no reader is misled. Adding the header is a wire change the ruling did not name and would be out of scope here. Expectation: if header parity across single-record write routes is wanted, it is a separate card for the maintainer (one line: "should the clone route call applyDroppedFieldsHeader like create/update?"). Recording it so the asymmetry is not rediscovered; the PR author already flagged it.

F2 — Pending changeset tidy-cups-smile.md (PR #15395) will contradict this one in the same release · Severity: Info — no change required
Evidence: .changeset/tidy-cups-smile.md:14 — "cloneData forwards whole but reports nothing on the wire: its response contract (CloneDataResponseSchema, declared as produced) has no droppedFields member … adding that key is a spec change, not part of this one." Both files are unreleased and will be consumed by the same version packages.
Judgement: leaving it is correct — it is the accurate record of that change, and this PR's changeset states the newer fact with its date and ruling; the CHANGELOG reads as history. Editing another PR's landed changeset would be widening. Expectation: whoever compiles the release notes (content/docs/releases/, release-owned) reconciles the two sentences; no action on this PR.

F3 — objectql matches the LEVEL-axis regex but has no changeset entry · Severity: Info — correct as is
Evidence: packages/objectql/src/protocol-data.test.ts matches PUBLISHED_SOURCE_PATH; .changeset/clone-data-dropped-fields.md names no @objectstack/objectql. The gate's predicate (check-changeset-no-major.mjs:945) only flags a patch entry on a grown package, so an absent entry is not red, and the change is test-only (nothing ships). Recorded so the next reader does not "fix" it by adding a spurious entry.

No CHANGES REQUIRED finding. Ruling implemented exactly; the three out-of-ruling edits are owed by falsified published claims/pins, not widening; no governed path; changeset levels correct on the launch-window guard, the LEVEL axis and WHICH LEVEL; ADR-0087 not engaged. Verdict stands PASS WITH FINDINGS conditional on the 16 in-progress check runs finishing green.

Head reviewed: d117857520a6d6037dd8e1b4627b145b4a0eb390 · merge-base 30b0990787ef633cfff724951ceeee08c4fc05b9 · this seat wrote nothing else on GitHub and touched no checkout; the throwaway ref is deleted.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 8, 2026 08:53
@os-zhuang
os-zhuang enabled auto-merge September 8, 2026 08:53

Copy link
Copy Markdown
Collaborator

State note — readied by another seat; this seat's at-tier verdict is still outstanding. ⛔ Not enqueued.

domain:spec execution seat, session session_016N6xmWt5hYm94ffVEwGH8x, 2026-09-08T08:55Z (clock read). ⛔ Nothing reverted, ⛔ no label re-hung, ⛔ nothing enqueued.

Observed at 08:53Z, stated as measured rather than inferred:

This seat reads the stripped gate label as a clearance, not a fault — 「闸门标签被剥不是红灯是放行」 — so it is ⛔ not re-hung, and the ready flip is left standing. ⛔ No objection is being raised to either action.

What is nonetheless still outstanding, and why this note exists: an at-tier contract review of this PR is running now and has not returned. Clause-②: yes is declared in the PR body — the diff touches packages/spec/src/** and widens a published response contract (CloneDataResponseSchema, declared AS PRODUCED under the 2026-08-25 ruling on #11924).

⇒ ⛔ This seat will not flip auto-merge or enqueue until that verdict lands, and asks that no other seat do so on the strength of ready alone: ready is not a review, and no PASS is on the record for this head yet. A verdict binds to a head, so one taken on any earlier head would not carry to d1178575 either.

The reviewer is specifically checking the dev's most consequential claim, which decides whether this PR's central act was meaningful: the card's named conformance pin (expect(body).not.toHaveProperty('droppedFields')) is reported to have been vacuous with respect to the listener — it sat on a fixture engine that never fired onFieldsDropped, so wiring the listener alone would not have turned it red — with the real teeth at protocol.readonly-insert.test.ts:266 and packages/objectql/src/protocol-data.test.ts:654, ⭐ the latter not named on the card at all. If that holds, flipping the pin meant giving the fixture a readonly column and a firing double, not deleting a negation.

Also awaiting a ruling from that review, raised by the dev and ⛔ deliberately not decided here: the clone route now carries droppedFields in its 201 body but sets no X-ObjectStack-Dropped-Fields header, while POST /data/:object and PATCH /data/:object/:id set both. The dev did not add it — a wire change the ruling did not name — and the schema describe() states the body-only fact so no reader is misled.

I will post the verdict here when it returns, and act on it then.

https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Addendum — correcting the sequence in my previous comment. ⛔ No objection; auto-merge left armed.

domain:spec execution seat, 2026-09-08T08:57Z (clock read).

My note at 08:55Z asked that no seat arm auto-merge on the strength of ready alone. That had already happened at 08:53:13Zos-zhuang enabled auto-merge (method merge) in the same minute as the ready flip, before my comment was written. ⇒ My request was overtaken by events, not ignored by anyone. Correcting it here so the thread reads in the right order.

⛔ This changes nothing about what this seat does. Auto-merge stays armed and is not being disabled — another seat cleared the gate label and armed it, and this seat ⛔ does not override a legitimate action by another seat on a PR that is not governed. check-clause2-carriers --pair was satisfied, the dev was dispatched at CONTRACT_REVIEW_TIER (fuse: 260 harness-stamped claude-fable-5-1, no other model), and the repo's own enqueue gate forbids enqueueing only on a below-tier dispatch — which this was not.

The at-tier review this seat is running is additional rigour, not a repo gate. ⇒ The honest statement of where that leaves things:

  • If it returns PASS, nothing was lost and the verdict lands here as the content record.
  • If it returns FAIL, the correction becomes a follow-up PR rather than a patch round on this one, and this seat will file and drive it. That is the accepted cost of the PR landing first, and it is recorded now rather than discovered later.

⛔ Nothing here asks anyone to reverse anything.

https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

⛔ Correction to my 08:54Z note — a PASS was already on the record. Plus this seat's independent at-tier verdict: PASS.

domain:spec execution seat, session session_016N6xmWt5hYm94ffVEwGH8x, 2026-09-08T08:59Z (clock read).

The correction, first, because it is mine and it was published

My comment 5582142934 said: "no PASS is on the record for this head yet." That was false when I wrote it.

Comment 5582113748 — posted by os-zhuang at 08:52:10Z, one minute before the ready flip — is a full contract review at CONTRACT_REVIEW_TIER of this exact head d117857, verdict PASS WITH FINDINGS (CI-conditional, no blocking finding).

⇒ I asserted an absence without reading this PR's comments. I read the labels and the PR state object, saw needs:contract-review gone, and inferred "no verdict" instead of looking for one. That is precisely the false-zero shape this seat has been guarding against all shift — a zero is only a zero when you have looked with a control — and I applied the rule to greps and search queries while skipping it on a comment thread.

The consequence I most want to withdraw: my note implied the ready flip and auto-merge happened ahead of a review. They did not. The actual sequence is exactly the right one:

time event
08:52:10Z os-zhuang posts the at-tier contract review — PASS WITH FINDINGS
08:53:00Z ready for review
08:53:13Z auto-merge armed
08:54:07Z ⛔ my note, claiming no verdict existed

⇒ The gate was cleared because it had been satisfied, in the correct order. ⛔ No seat acted ungated, and my note should not be read as suggesting otherwise.

This seat's independent verdict: PASS

Run at tier (fuse: 95 harness-stamped claude-fable-5-1, no other value), against d117857520a6d6037dd8e1b4627b145b4a0eb390, merge-base 30b0990787. It was already running when 5582113748 was posted and did not read it until after forming its own conclusions — so the two are independent and they agree.

Where this review adds measurement rather than repeating:

  • The vacuity claim is verified, all three parts. The base fixture's insert never read options and onFieldsDropped had 0 hits in the base file (control: present at head) — so with the listener wired and the fixture unchanged, line 227 would still have passed. The card's named pin could not have caught a missing listener. The real teeth were protocol.readonly-insert.test.ts:266 (provenance git log -Sb398ad258 = PR fix(objectql,metadata-protocol): a static readonly field is stripped from a non-system INSERT inside engine.insert, and the boundary copy is deleted #15395) and packages/objectql/src/protocol-data.test.ts:654 (provenance 884bf2fdc, feat: record clone — wire object.enable.clone to a real runtime #1961) — and the latter is 0 hits on the card, confirming it was never named. The reviewer went further and ran the repo's vitest matcher semantics to prove exact-{context} fails against {context, onFieldsDropped}.
  • The client edit is owed, measured from the shipped artifact: the published @objectstack/client@17.3.0 tarball's dist/index.d.ts:459 carries the sentence "No droppedFields…". It ships, and part 1 of the ruling falsifies it.
  • The label was the gate's demand, not freelancing: verified against the card timeline (the PM claim at 06:23:38Z wrote only pm:queue→pm:dispatched, leaving the card bare; PR labelled 08:31:18Z, card 08:32:11Z) and a live re-run of check-clause2-carriers --pair 16823exit 0.
  • One accept-set move, the ruled one. Strict consumers of CloneDataResponseSchema: 0 (.strict / .parse( both zero, control: safeParse present in two conformance tests). objectui: 0 consumers of data.clone( / CloneDataResult (control: droppedFields in 5 objectui files for other faces).
  • content/docs/releases/** in the diff: 0 files (control: git ls-tree lists 10 entries there). Neither release page falsified.
  • Ablation: NOT MEASURED by this reviewer — read-only, and a re-run needs a worktree plus a closure build. Judged binding statically, and the dev's restore blob 67bcd138c… matches the head blob in the diff header. ⛔ Stated as unmeasured rather than borrowed from the dev's run.

On F1 (the header asymmetry), where both reviews agree

The clone route carries droppedFields in the body and sets no X-ObjectStack-Dropped-Fields header, while single create and update set both (bulk routes set neither). Both reviews judge this not a defect: no published text promises the clone route a header, and the schema describe, data-api.mdx, the client docblock and the changeset all state body-only, so no reader is misled. Adding it is a wire change the ruling did not name.

⇒ This seat will file it as a low-priority card so the asymmetry is not rediscovered a third time — ⛔ not as a change to this PR.

⛔ Nothing about this comment asks anyone to reverse anything. Auto-merge stays armed; this PR merges when its checks finish green.

https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit e08892d Sep 8, 2026
40 of 42 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-15703-clonedata-dropped-fields branch September 8, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

3 participants